Repository navigation
Declare a primary key, and let psql print it - #138
Merged
Merged
Conversation
Table takes primary_key, one column or a tuple of them, normalised to a tuple so
nothing downstream branches on the spelling and validated at construction
against the table's own columns -- a key naming a column the table does not have
is knowable there and should raise on the line that wrote it. A tuple rather
than a set because a composite key's order is part of it: it is what conkey
records and what psql prints.
`\d commits` now ends with
Indexes:
"commits_pkey" PRIMARY KEY, btree (sha)
which is byte-for-byte what PostgreSQL 18 prints for the same declaration.
Getting there needed four things that are each invisible on their own:
- pg_class.relhasindex, which psql gates the entire footer on. Left False, the
rows below are built and never asked for.
- a pg_class row for the *index*, relkind 'i'. psql reads the index's name as
c2.relname off a second pg_class in the same query, so without it the join
drops every index it was told about.
- pg_get_indexdef, which cannot be the constant "" the other catalog functions
are: psql echoes everything after " USING " straight into the footer, so that
substring is where `btree (sha)` comes from. It is rewritten into a reference
to a column carrying the rendered text, exactly as format_type already is, and
pg_get_constraintdef gets the same treatment for #129's benefit.
- pg_index and pg_constraint leaving catalog_rewrite's _ALWAYS_EMPTY_TABLES.
Safe because psql reads both in a JOIN rather than in one of the correlated
subqueries that substitution exists for.
A key's columns are reported not null through both attnotnull and
information_schema.columns.is_nullable, which had to be done in both places --
they answer one question and a client reads whichever it reads. PostgreSQL 17
also records them as contype='n' rows in pg_constraint; pg_mimic reports
server_version 16, so it does not.
Index and constraint OIDs come from their own ranges rather than continuing the
table numbering, so declaring a key cannot renumber a table -- table OIDs are
positional and a client may have introspected them already. There is a test for
that specifically.
Identifiers in a rendered definition are quoted only when they have to be, which
is what pg_get_constraintdef does: btree (sha) and btree ("userId").
examples/git_sql.py declares the four keys that are actually sound for git data:
commits.sha, files.path, branches.name and commit_files (sha, path).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jbylund
force-pushed
the
feat/declared-primary-key
branch
from
August 17, 2026 17:26
bc0c255 to
e5ce3b9
Compare
jbylund
added a commit
that referenced
this pull request
Aug 17, 2026
Closes #128. **Stacked on #138**, which is stacked on #137 — this PR targets #138's branch, so the diff here is only the unique-constraint work. `Table` takes `unique`, any number of keys spelled the way `primary_key` is: ```python Table("users", {"id": "integer", "email": "text", "tenant": "text"}, primary_key="id", unique=["email", ("tenant", "email")]) ``` Output is byte-identical to PostgreSQL 18 for the same declaration, ordering included — psql sorts the footer `indisprimary DESC, relname`: ``` Indexes: "u1_pkey" PRIMARY KEY, btree (id) "u1_a_b_key" UNIQUE CONSTRAINT, btree (a, b) "u1_email_key" UNIQUE CONSTRAINT, btree (email) "u1_oddName_key" UNIQUE CONSTRAINT, btree ("oddName") ``` ## Most of the diff is undoing a one-key-per-table assumption #128 was supposed to be small, and the declaration half was. The catalog half was not, because #127 had baked in "at most one key per table" in two places: - **Index and constraint OIDs came from the table's position** (`_FIRST_INDEX_OID + index`), so a table with three unique constraints would have issued three rows sharing one OID. - **The row builder was a single `if table.primary_key:` block.** Both are now driven by a running count over the rows actually emitted. Worth knowing for #129, which adds a third kind of constraint to the same loop. ## Where the two kinds of key actually differ They share one code path that branches in exactly three places, which is the useful summary: | | primary key | unique | | --- | --- | --- | | `contype` / rendering | `p` / `PRIMARY KEY (...)` | `u` / `UNIQUE (...)` | | `indisprimary` | true | false — psql reads this to pick the footer label | | columns implicitly NOT NULL | yes | **no** | The third is the one substantive difference, and it has a test asserting `\d u1` says `not null` exactly once. ## Naming Index names are *relation* names, so they are unique across the schema rather than per table, and generated the way Postgres generates them: `t_pkey`, or `t_<columns>_key` with the column names unquoted whatever they are — Postgres builds `u1_oddName_key` from `"oddName"` and quotes the whole name only when printing it. Truncated to 63 bytes (NAMEDATALEN - 1), which is reachable with an ordinary long table name plus two columns, and a truncation that collides gets a counter as Postgres' own `ChooseIndexName` does. Identical *keys* are refused at declaration instead, since that is a copy-paste rather than a name clash. ## Tests 18 new. `test_declared.py` for normalisation and the three refusals; `test_pg_catalog.py` running psql's footer query verbatim and asserting the full ordered row set, plus the truncation and OID-uniqueness cases; `test_psql.py` asserting all four rendered footer lines against the real binary. Full suite: **946 passed, 32 xfailed** (up from 928). `ruff check` and `ruff format --check` clean. ## Not in scope Foreign keys (#129) and declaring a constraint after construction (#130). Unique *indexes* that are not constraints — `CREATE UNIQUE INDEX` without `ADD CONSTRAINT`, which psql prints as `UNIQUE,` rather than `UNIQUE CONSTRAINT,` — have no declaration surface and nothing needs them. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #127. Stacked on #137 (
feat/declared-table-and-schema) — review that first; this PR targets its branch, so the diff here is only the primary-key work.Tabletakesprimary_key, one column or a tuple of them:Normalised to a tuple so nothing downstream branches on the spelling, and validated at construction against the table's own columns — a key naming a column the table does not have is knowable there, so it raises on the line that wrote it. A tuple rather than a set because a composite key's order is part of it: it is what
conkeyrecords and what psql prints.What
\dprints nowByte-for-byte what PostgreSQL 18 prints for the same declaration — I diffed them against a real server rather than reconstructing the format.
Four things that are each invisible on their own
This is most of the work, and none of it is guessable from the outside:
pg_class.relhasindexgates the entire footer. LeftFalse, every row below is built and never asked for.pg_classrow,relkind = 'i'. psql reads the index's name asc2.relnameoff a secondpg_classin the same query, so without it the join drops every index it was told about.pg_get_indexdefcannot be the constant""the other catalog functions are. psql echoes everything after" USING "straight into the footer, so that substring is wherebtree (sha)comes from. It is rewritten into a reference to a column carrying the rendered text — exactly asformat_typealready was — andpg_get_constraintdefgets the same treatment, which Declare foreign keys between Tables, resolved by name #129 will need.pg_indexandpg_constraintleave_ALWAYS_EMPTY_TABLES. Safe because psql reads both in a JOIN, not in one of the correlated subqueries that substitution exists for; I checked every query\dsends.Details worth a look in review
not nullis written in two places on purpose.attnotnullandinformation_schema.columns.is_nullableanswer one question and a client reads whichever it reads, so they must not disagree. There's a test asserting both.Index and constraint OIDs come from their own ranges, not the continuation of the table numbering — declaring a key must not renumber a table, since table OIDs are positional and a client may have introspected them already.
test_declaring_a_primary_key_does_not_renumber_the_tablespins that.PostgreSQL 17 also records key columns as
contype='n'rows inpg_constraint. pg_mimic reportsserver_version 16, so it deliberately does not.Identifiers are quoted only when they have to be, which is what
pg_get_constraintdefdoes —btree (sha)andbtree ("userId"). Reserved words are not consulted; the comment says why.Tests
20 new, at three levels:
test_declared.py— normalisation, key order, and the two refusals.test_pg_catalog.py— psql's index-footer query verbatim, against one-column, composite and needs-quoting keys, plus a table with no key answering no rows. Also the OID-stability andconkeyassertions.test_psql.py— the real psql binary, asserting the rendered footer string, that a keyless table has noIndexes:section, that\dilists the index, and that\dtdoes not — an index is a relation, so thepg_classrow it needs must not leak into the table list.Full suite: 928 passed, 32 xfailed (up from 908).
ruff checkandruff format --checkclean.Also here
examples/git_sql.pydeclares the four keys that are actually sound for git data:commits.sha,files.path,branches.nameandcommit_files (sha, path). Verified live.Not in scope
Unique constraints (#128), foreign keys (#129), declaring a constraint after construction (#130).
pg_get_constraintdefis made row-dependent here but no non-primary constraint exists to render yet.